pg_procrustes

A fast, flexible PostgreSQL formatter in Go. Driven by the native PostgreSQL parser, it understands your code like the database itself.

Go PostgreSQL MIT

The name

In Greek mythology, Procrustes was a bandit who operated an iron bed on the road to Athens. He invited travellers to spend the night — then made sure they fit the bed perfectly. If a guest was too tall, he cut off their legs. If too short, he stretched them until they matched. He actually kept two beds: one small, one large — so no guest could ever escape adjustment. Either way, everyone fit.

pg_procrustes works the same way. You define the bed — the formatting rules — via .pg_procrustes.yaml. Your SQL will fit. Whether it likes it or not.

Installation

Homebrew (macOS and Linux)

brew install heptau/tap/pg-procrustes

Binary download

Download the archive for your platform from GitHub Releases, extract, and place the binary on your PATH.

ArchivePlatform
pg_procrustes-<ver>-darwin-arm64.tar.gzmacOS Apple Silicon
pg_procrustes-<ver>-darwin-amd64.tar.gzmacOS Intel
pg_procrustes-<ver>-linux-amd64.tar.gzLinux x86-64
pg_procrustes-<ver>-linux-arm64.tar.gzLinux ARM64
pg_procrustes-<ver>-windows-amd64.zipWindows x86-64
pg_procrustes-<ver>-windows-arm64.zipWindows ARM64

go install (requires Go toolchain)

go install github.com/heptau/pg_procrustes/cmd/pg_procrustes@latest

Build from source

# current platform
make build

# raw binaries for all platforms
make build-all

Editor integration

This repository doubles as a Neovim plugin. Requires the pg_procrustes binary on your PATH (see Installation above).

Neovim

With lazy.nvim:

-- lazy.nvim
{
  "heptau/pg_procrustes",
  ft = "sql",
  config = function()
    require("pg_procrustes").setup()
  end,
}

With packer.nvim:

-- packer.nvim
use({
  "heptau/pg_procrustes",
  config = function()
    require("pg_procrustes").setup()
  end,
})

If conform.nvim is installed, sql buffers get pg_procrustes registered as a formatter automatically — require("conform").format() and format-on-save pick it up like any other formatter. Without conform.nvim, use :PgProcrustesFormat to format the current buffer directly. Run :checkhealth pg_procrustes to verify the setup, or :help pg_procrustes for the full reference.

Zed

Zed has built-in support for external formatters — no extension needed. Add this to your project settings (.zed/settings.json) or user settings (~/.config/zed/settings.json):

// .zed/settings.json
{
  "languages": {
    "SQL": {
      "formatter": {
        "external": {
          "command": "pg_procrustes",
          "arguments": []
        }
      }
    }
  }
}

Then format with the editor: format command, or set "format_on_save": "on".

VS Code

VS Code has no built-in "external formatter" option, but the Custom Local Formatters extension adds one purely through settings — no extension of your own required. Install it, then add:

// settings.json
"customLocalFormatters.formatters": [
  {
    "command": "pg_procrustes",
    "languages": ["sql"]
  }
],
"[sql]": {
  "editor.defaultFormatter": "jkillian.custom-local-formatters"
}

Format Document (Shift+Alt+F) and editor.formatOnSave will now run pg_procrustes.

DataGrip & other JetBrains IDEs

DataGrip bundles the File Watchers plugin (enable it under Settings → Tools → File Watchers if it's off). Add a custom watcher:

File typeSQL
Programpath to pg_procrustes (or just the binary name if it resolves on PATH)
Arguments-w $FilePath$
Output paths to refresh$FilePath$

The file is reformatted in place on every save. For a manual, on-demand trigger instead, use Settings → Tools → External Tools with the same Program/Arguments.

Emacs

With apheleia.el (async, keeps point position):

;; init.el
(setf (alist-get 'pg-procrustes apheleia-formatters) '("pg_procrustes"))
(setf (alist-get 'sql-mode apheleia-mode-alist) 'pg-procrustes)
(add-hook 'sql-mode-hook #'apheleia-mode)

Or with the lighter reformatter.el:

;; init.el
(reformatter-define pg-procrustes-format
  :program "pg_procrustes")
(add-hook 'sql-mode-hook #'pg-procrustes-format-on-save-mode)

Either gives you format-on-save without writing a package of your own.

Helix

Add to ~/.config/helix/languages.toml (or a project-local .helix/languages.toml):

# languages.toml
[[language]]
name = "sql"
formatter = { command = "pg_procrustes" }
auto-format = true

Format on demand with :format, or let auto-format run it on every save.

Sublime Text

Install Fmt via Package Control, then add to its settings (Preferences → Package Settings → Fmt → Settings):

// Fmt.sublime-settings
{
  "rules": [
    {
      "selector": "source.sql",
      "cmd": ["pg_procrustes"],
      "format_on_save": true,
      "merge_type": "diff"
    }
  ]
}

Or trigger it manually from the command palette with Fmt: Format Buffer.

Vim (via ALE)

With ALE installed, define a fixer in your .vimrc:

" .vimrc
let g:ale_fixers = {
\   'sql': [
\     {buffer -> {'command': 'pg_procrustes'}},
\   ],
\}
let g:ale_fix_on_save = 1

Or run it on demand with :ALEFix.

Zed, VS Code, DataGrip, Emacs, Helix, Sublime Text, and Vim above are config-only integrations — no dedicated plugin required, though one may follow later. If your editor launches as a GUI app rather than from a terminal, it may not see your shell's PATH; use the absolute path from which pg_procrustes if the command isn't found.

Automation & CI

pre-commit

Add a pre-commit local hook (requires pg_procrustes on PATH — see Installation) to .pre-commit-config.yaml:

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: pg_procrustes
        name: pg_procrustes
        language: system
        entry: pg_procrustes --check
        files: \.sql$

--check fails the commit without touching files, so you review the diff and run pg_procrustes -w yourself. Prefer autofix-on-commit instead? Swap the entry for pg_procrustes -w — pre-commit detects that files were modified and still fails that run, so you can review, re-stage, and commit again.

GitHub Actions

A minimal CI job that fails a pull request if any tracked .sql file isn't formatted:

# .github/workflows/pg_procrustes.yml
name: SQL formatting
on: [pull_request]

jobs:
  pg_procrustes:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: go install github.com/heptau/pg_procrustes/cmd/pg_procrustes@latest
      - run: pg_procrustes --check $(git ls-files '*.sql')

Swap the go install step for the Homebrew or binary download methods if you'd rather not depend on a Go toolchain in CI.

Usage

# format a file in place
pg_procrustes -w query.sql
pg_procrustes -w migrations/*.sql

# print formatted output to stdout
pg_procrustes query.sql

# read from stdin, write to stdout
cat query.sql | pg_procrustes

# CI mode — exit 1 if any file would change
pg_procrustes --check *.sql

# show a unified diff without writing
pg_procrustes --diff query.sql

# show version
pg_procrustes --version

Flags

FlagDescription
-v, --versionPrint version and exit
-c <path>Path to config file (default: auto-detect .pg_procrustes.yaml)
-wWrite result back to source files (in-place)
--backup[=.ext]Save the original before overwriting (requires -w); default extension is .bak
--out-dir <dir>Write formatted files into this directory instead of in-place (cannot be combined with -w)
--checkExit 1 if any file would be reformatted — useful in CI and pre-commit hooks
--diffPrint a unified diff of changes without writing

Config file lookup

pg_procrustes looks for .pg_procrustes.yaml starting in the current directory and walking up to the filesystem root. Use -c to specify a path explicitly. All settings default to preserve — if no config is found, nothing changes.

Keyword & identifier casing

Each token class can be set to upper, lower, or preserve.

reserved_keywords:
  case: upper     # SELECT, FROM, WHERE, JOIN, …

keywords:
  case: upper     # ANALYZE, CASCADE, VERBOSE, …

data_types:
  case: lower     # integer, text, timestamp, …
  form: long      # long | short | long_no_space | preserve

literals:
  case: upper     # TRUE, FALSE, NULL

operators:
  case: upper     # AND, OR, NOT, IN, IS, LIKE, BETWEEN, …

schemas:    { case: lower }
tables:     { case: lower }
columns:    { case: lower }
functions:  { case: lower }
conditional_functions:  { case: upper }  # COALESCE, NULLIF, GREATEST, LEAST
system_functions:       { case: upper }  # CURRENT_DATE, SESSION_USER, …

aliases:
  case: lower
  as: add         # add | preserve | remove

Casing exceptions

Any casing section accepts an exceptions list. Words in the list keep their exact capitalisation regardless of the section's case setting.

reserved_keywords:
  case: upper
  exceptions:
    - select    # stays lowercase even though case: upper
    - from

columns:
  case: lower
  exceptions:
    - ID        # stays "ID" instead of "id"
    - CreatedAt

functions:
  case: lower
  exceptions:
    - MyFunc

Exceptions are matched case-insensitively — the value in the list defines the exact output casing.

Data type forms

formExample output
longcharacter varying, double precision, timestamp with time zone
shortvarchar, float8, timestamptz
long_no_spaceinteger, bigint, varchar
preserveLeft as written

PL/pgSQL variables & keywords

Two dedicated sections for tokens that the PostgreSQL scanner classifies as NO_KEYWORD — they are invisible to the standard keywords / reserved_keywords rules.

plpgsql_variables

Runtime pseudo-variables available inside trigger and function bodies:

plpgsql_variables:
  case: upper   # upper | lower | preserve
  # Covers: NEW, OLD, EXCLUDED (ON CONFLICT),
  #         FOUND, TG_OP, TG_TABLE_NAME, TG_TABLE_SCHEMA,
  #         TG_NAME, TG_WHEN, TG_LEVEL, TG_NARGS, TG_ARGV,
  #         TG_RELID, TG_RELNAME, TG_EVENT, TG_TAG,
  #         SQLSTATE, SQLERRM, ROW_COUNT, PG_CONTEXT,
  #         PG_EXCEPTION_DETAIL, PG_EXCEPTION_HINT, PG_EXCEPTION_CONTEXT,
  #         RETURNED_SQLSTATE, MESSAGE_TEXT, PG_DATATYPE_NAME, PG_ROUTINE_OID
Before
IF tg_op = 'INSERT' THEN
  INSERT INTO audit(tbl, op, new_id)
    VALUES(tg_table_name, tg_op, new.id);
END IF;
RETURN new;
After — plpgsql_variables: upper
IF TG_OP = 'INSERT' THEN
  INSERT INTO audit(tbl, op, new_id)
    VALUES(TG_TABLE_NAME, TG_OP, NEW.id);
END IF;
RETURN NEW;

plpgsql_keywords

PL/pgSQL statement keywords not classified as SQL keywords by the scanner:

plpgsql_keywords:
  case: upper   # upper | lower | preserve
  # Covers: RAISE, PERFORM, ELSIF, ELSEIF, FOREACH, REVERSE,
  #         SLICE, EXIT, LOOP, WHILE, OPEN, ASSERT,
  #         DEBUG, INFO, NOTICE, WARNING, EXCEPTION (as RAISE severity / handler)
Before
loop
  exit when i >= n;
  raise notice 'i=%', i;
  i := i + 1;
end loop;
After — plpgsql_keywords: upper
LOOP
  EXIT WHEN i >= n;
  RAISE NOTICE 'i=%', i;
  i := i + 1;
END LOOP;

Both sections accept an exceptions list — words matching an exception keep their original capitalisation.

Punctuation & spacing

trailing_whitespace:  strip      # strip | preserve
semicolons:           preserve   # preserve | add | remove
inequality_op:        c          # preserve | ansi (<>) | c (!=)
join_form:            preserve   # preserve | short | long
operator_spacing:     normalize  # preserve | normalize | compact
comma_spacing:        normalize  # preserve | normalize | compact
blank_lines:          preserve   # preserve | max_3 | max_2 | max_1
paren_spacing:        remove     # preserve | add | remove
quoted_identifiers:   remove_safe
schema_qualification: preserve   # preserve | remove_public
cast_style:           preserve   # preserve | operator  (CAST(x AS t) → x::t)
order_asc:            preserve   # preserve | add | remove
not_in:               preserve   # preserve | not_in | not_equals_all

Layout

The layout section controls clause-level line breaking and indentation. All modes default to preserve.

layout:
  line_length: 128

  indent:
    size: 3           # spaces per level (ignored when type: tab)
    type: spaces      # spaces | tab
    normalize: preserve
    remainder: keep   # keep | add | remove | round

  union:
    blank_line: preserve   # preserve | none | before | after | both

  paren_indent:
    mode: preserve         # preserve | indent | none
    close_first_on_line: same  # same | after

union.blank_line

Controls blank lines around UNION, UNION ALL, INTERSECT, and EXCEPT.

valueEffect
noneNo blank lines — set operators on same flow as surrounding SELECTs
beforeBlank line before the set operator keyword
afterBlank line after the set operator keyword
bothBlank line before and after

paren_indent

mode: indent re-indents content inside multi-line parenthesised blocks (subqueries, IN (…)) to exactly N spaces per depth level. close_first_on_line: after places the closing ) on its own line at the inner indentation level.

Clause breaking

clauses.break: auto puts each SQL clause on its own line when the flat statement exceeds line_length. Breaking is all-or-none.

Individual clauses can override the default:

layout:
  clauses:
    break: auto       # preserve | never | always | auto
    align: same       # same | indent
    join:  { break: always }
    limit: { break: never  }
Before
SELECT u.id, u.name FROM users u INNER JOIN orders o ON o.user_id = u.id WHERE u.active = TRUE ORDER BY u.name
After — break: auto, line_length: 80
SELECT u.id, u.name
FROM users u
INNER JOIN orders o ON o.user_id = u.id
WHERE u.active = TRUE
ORDER BY u.name

Content breaking

Controls how items inside a clause are laid out. SELECT and RETURNING split at commas; WHERE, HAVING, and JOIN conditions split at AND/OR (the operator is placed at the start of the continuation line).

Break modes

modeEffect
preserveLeave as written (default)
neverAlways collapse to one line
alwaysAlways break each item onto its own line
autoBreak when clause content length exceeds line_length

first_item

first_item controls where the first item goes when breaking is active. Independent of the break mode.

valueEffect
breakFirst item on a new indented line (default)
inlineFirst item stays on the same line as the clause keyword
layout:
  content:
    break: auto        # global default for all sections
    align: indent      # same | indent
    first_item: break  # break | inline
    # per-section overrides — each inherits content settings when omitted:
    select_list:    { break: always, first_item: inline }  # SELECT column list (comma)
    where_conds:    { break: always, first_item: inline }  # WHERE conditions (AND/OR)
    having_conds:   { break: always, first_item: inline }  # HAVING conditions (AND/OR)
    join_on:        { break: always }                      # JOIN ON conditions (AND/OR)
    group_list:     { break: auto }                        # GROUP BY items (comma)
    order_list:     { break: auto }                        # ORDER BY items (comma)
    set_list:       { break: always }                      # UPDATE SET assignments (comma)
    insert_columns: { break: always, first_item: inline }  # INSERT INTO table(col1, col2, …)
    values_list:    { break: always }                      # VALUES row tuples
    returning_list: { break: always, first_item: inline }  # RETURNING items (comma)
    with_list:      { break: always }                      # WITH CTE definitions (comma)

break: always + first_item

first_item: break (default)
SELECT
   u.id,
   u.name,
   u.email
WHERE
   u.active = TRUE
   AND u.type = 'premium'
first_item: inline
SELECT u.id,
   u.name,
   u.email
WHERE u.active = TRUE
   AND u.type = 'premium'

INSERT column list

insert_columns controls the (col1, col2, …) list after the table name in INSERT statements. For always the opening paren gets its own line; for first_inline the first column stays on the paren line.

insert_columns: always
INSERT INTO users (
   id,
   name,
   email
)
VALUES (...)
insert_columns: first_inline
INSERT INTO users (id,
   name,
   email
)
VALUES (...)

WITH clause

with_list: always puts each CTE definition on its own indented line:

with_list: always
WITH
   active_users AS (SELECT id, name FROM users WHERE active = TRUE),
   recent_orders AS (SELECT user_id, count(*) FROM orders GROUP BY user_id)
SELECT * FROM active_users

CASE expressions

Controls SQL CASE … END expressions in SELECT, WHERE, and other clauses.

layout:
  case:
    break: preserve   # preserve | never | always | auto
    indent: indent    # preserve | none | indent
breakEffect
neverCollapse CASE…END to a single line
alwaysEach WHEN / ELSE / END on its own line
autoExpand only when the flat CASE length exceeds line_length
preserveLeave as written
Before
SELECT
  CASE WHEN status = 'active'
       THEN 1
       WHEN status = 'pending'
       THEN 0
       ELSE -1
  END
FROM t
After — break: never
SELECT CASE WHEN status = 'active' THEN 1 WHEN status = 'pending' THEN 0 ELSE -1 END
FROM t

PL/pgSQL formatting

Dollar-quoted function and procedure bodies are formatted recursively. The layout.dollar_quote.plpgsql section controls block structure.

layout:
  dollar_quote:
    newline_after_open:   preserve  # preserve | add | remove
    newline_before_close: preserve

    sql:                            # for LANGUAGE sql bodies
      body_indent:       preserve   # preserve | none | indent
      blank_line_before: preserve   # blank line after opening $$
      blank_line_after:  preserve   # blank line before closing $$

    plpgsql:
      keyword_indent:     preserve  # preserve | none | indent  (DECLARE/BEGIN/END)
      declare_when_empty: preserve  # preserve | add | remove
      end_semicolon:      preserve  # preserve | add | remove

      declare:
        indent:            preserve
        blank_line_before: preserve
        blank_line_after:  preserve

      begin_body:
        indent:            preserve
        blank_line_before: preserve
        blank_line_after:  preserve

PL/pgSQL — IF blocks

layout:
  dollar_quote:
    plpgsql:
      control_flow:
        if:
          body_indent:       preserve  # preserve | none | indent
          blank_line_before: preserve  # preserve | add | remove
          blank_line_after:  preserve
Before
BEGIN
IF x = 1 THEN
y := 'a';
ELSIF x = 2 THEN
y := 'b';
ELSE
y := 'c';
END IF;
END
After — body_indent: indent
BEGIN
   IF x = 1 THEN
      y := 'a';
   ELSIF x = 2 THEN
      y := 'b';
   ELSE
      y := 'c';
   END IF;
END

PL/pgSQL — LOOP blocks

Covers plain LOOP, FOR i IN … LOOP, FOR rec IN SELECT … LOOP, and WHILE cond LOOP. The loop header is preserved verbatim.

layout:
  dollar_quote:
    plpgsql:
      control_flow:
        loop:
          body_indent:       preserve
          blank_line_before: preserve
          blank_line_after:  preserve
Before
BEGIN
FOR i IN 1..10 LOOP
total := total + i;
END LOOP;
END
After — body_indent: indent, blank_line_after: add
BEGIN
   FOR i IN 1..10 LOOP

      total := total + i;

   END LOOP;
END

PL/pgSQL — CASE statements

Two independent configs: simple (value switch) and searched (condition switch).

layout:
  dollar_quote:
    plpgsql:
      control_flow:
        case:
          simple:                   # CASE expr WHEN value THEN …
            when_indent:  preserve  # preserve | none | indent
            then_break:   preserve  # preserve | never | always | auto
            then_indent:  preserve  # preserve | none | indent
            body_break:   preserve  # preserve | never | always | auto
            body_indent:  preserve  # preserve | none | indent
            blank_line_before: preserve
            blank_line_after:  preserve
          searched:               # CASE WHEN condition THEN …
            # same settings as simple
SettingValuesEffect
when_indentindentWHEN/ELSE one level deeper than CASE
then_breakneverWHEN val THEN body stays on one line
alwaysTHEN on its own line
body_breakneverBody starts on the THEN line
alwaysBody on new line after THEN
autoSame line for single stmt, new line for multiple
Compact style
BEGIN
   CASE status
      WHEN 'active'  THEN result := 1;
      WHEN 'pending' THEN result := 0;
      ELSE               result := -1;
   END CASE;
END
Expanded style
BEGIN
   CASE
   WHEN status = 'active'
      THEN
         result := 1;
   WHEN status = 'pending'
      THEN
         result := 0;
   ELSE
      result := -1;
   END CASE;
END